Go内存模型,调用机制和PLAN9汇编
Go逆向_2
环境
1 | go version go1.16.4 windows/amd64 |
是去年7月份下的。但是Go语言底层基本没什么变化,所以就暂且用这个版本。
基本使用
Go配备了一个齐全的工具链。
打印PLAN 9汇编
1 | go build -gcflags -S xxx.go |
这将直接在控制台界面上打印PLAN9汇编。
生成.o
,.exe.
以及PLAN9汇编
1 | go tool compile -S xxx.go |
直接全都生成了。
反汇编
1 | go build -o xxx.exe xxx.go |
反汇编出main.main
函数的PLAN9汇编。
注意仅对未strip的程序有效。
关于-gcflags
和-ldflags
传参
-gcflags
gcflags
就是传入compile
工具的参数。所以
1 | go tool compile --help |
可以浏览-gcflags
所有可传入参数。
调试情况下,通常使用的参数是:
-N
:不优化-l
:不内联
1 | go build -gcflags |
-ldflags
是链接参数,传入link
工具的。
1 | go tool link --help |
可以知晓所有参数。
可以传入
-compressdwarf=false
参数,使得不压缩DWARF调试信息。
Go PLAN9汇编
Go汇编和一般x86汇编极其相似。此块作为参考部分使用,以防一些因细微差异导致的误解。
本文参考官方开发文档 https://go.dev/doc/asm#symbols 解释的比其他人的博客都要详细很多。
虽然本文处于开发角度而言的,且Go底层API很多都是直接靠Go汇编写出来的,所以还是值得一学。
Plan 9是个很古老的汇编体系。Go语言使用Plan 9汇编就是为了增加抽象性,便于跨平台。
不过,Go的Plan 9和最原始的Plan 9还是有不少区别的。
想看原始版本的可以看看这篇文章。
https://9p.io/sys/doc/asm.html
里面的寄存器为了适应多平台,起名皆是高度抽象的。不是本文重点。
本博客主要谈谈基于AMD64
架构的Go Plan 9汇编,其中寄存器起名和实际寄存器都区别不大了;但是还是
简要
首先需要了解Go汇编的是,Go汇编不是直接描述某一种机器的汇编语言(比如x86,AMD64,MIPS,PPC等平台相关汇编)。Go汇编其中的某一些细节将精确地对应其机器平台,但是还有一些则不是。这是因为编译器套件在现在的编译流程中并不需要汇编器流程。相反的,Go编译器运行在一种半抽象指令集上,且指令选择部分发生于代码生成后。
所以说当你发现一个像MOV
的Go汇编,工具链实际生成的机器汇编可能是load
或者clear
(众RISC架构)。当然也有可能MOV
Go汇编正好就对应这机器平台上的mov
汇编(x86,AMD64平台概率大)。
总结:Go汇编是抽象(Plan9)与具体架构的混合。
例子
先用go build -gcflags -S hello.go
正向汇编程序。
1 | go build -gcflags -S .\hello.go |
可以发现存在FUNCDATA
和PCDATA
这类与x86-64
机器平台对应不上的抽象指令集。这两个指令包含了用于垃圾回收(GC)的信息。
然后反向将程序反汇编出Go汇编,观察实际二进制指令。
1 | go tool objdump -s main.main hello.exe |
可以发现指令集和寄存器的使用都已经非常贴近实际的AMD64
平台了。(当然实际上还不是能完全对的上具体寄存器,但是指令集已经很接近了)
常量解析
Go汇编器对于常量表达式的解析和原生Plan9还是有区别的。Go汇编器遵循Go语言自己的表达式优先级,而非C语言风格的优先级。
比如3&1<<2
在Go中会解析为(3&1)<<2
,而不是3&(1<<2)
。
伪寄存器
Go汇编中预定义了4个伪寄存器。这四个是无关机器平台的,任意架构的都会用上。
FP
:帧指针(Frame Pointer),用于指向函数帧与传参。PC
:程序计数器(Program Counter),控制程序走向,跳转与分支(就是常见的eip
或rip
)。SB
:静态栈底指针(Static Base pointer),指向全局变量。SP
:栈指针(Stack Pointer),指向局部栈帧的最高地址处。
FP
FP寄存器是一个指向函数参数的栈帧指针,通常用FP加上一个偏移来访问函数的参数和返回值。例如用0(FP)访问第一个参数,8(FP)
访问第二个参数(在64位机器上,偏移是默认8字节)。但是为了便于阅读,生成Go汇编时还需要再前面加上参数的名称:first_arg+0(FP)
、second_arg+8(FP)
,其中+
并没有任何意义,只用作分隔符。汇编器强制这种形式,拒绝一般的0(FP)
和8(FP)
。值得一提的是FP
总是一个伪寄存器。
- 根据官方博客,在正向生成代码的时候
FP
会用上,反编译后就会变成具体的基于架构的寄存器了。- 实际上暂时还没碰见,遇到的都是诸如
AX
,CX
等偏具体的寄存器名了。
SB
SB
可以被看作内存段的起点,所以符号foo(SB)
就是foo
所在地址。这种形式被用于命名全局函数和数据。通过添加<>
到名字上,比如foo<>(SB)
,会让此名字仅在此源代码文件中可见,相当于C语言中的static
声明。在名字上添加偏移代表了相应偏移的地址位,比如foo+4(SB)
代表了foo
后跨过四字节的位置。
- 比如
CALL fmt.Fprintln(SB)
就是调用fmt.Println
函数,其中就是用SB
寄存器指向全局函数的。- 未
strip
的Go程序用go tool objdump
反汇编后还会保留此类信息。
SP
SP
是虚拟栈指针。指向局部帧变量和准备用于函数调用的参数。它指向局部栈帧的最高地址位,所以任何偏移都应该是取负数地址,[-framesize, 0)
。比如x-8(SP)
,y-4(SP)
。
若某些架构上有名为SP
的指针,要注意区分。有名称前缀的是Go中的虚拟栈指针,比如x-8(SP)
;而-8(SP)
则是硬件上的SP
指针。
不用太纠结于此,反正知道在AMD64上,
SP
和真正的寄存器是不一样的。比如我通过用IDA反汇编得知实际上使用的是rax
寄存器。所有用户定义的符号都会被写成
SB
和FP
加偏移的形式,而SP
寄存器包含了FP
的功能。 其实在我看到的x86下的Go汇编中,编译器一般不使用FP
寄存器, 而是使用SP
来访问局部变量和函数参数。
PC
PC
为程序计数器,指向当前程序的执行位置,在x86中它是eip
或rip
寄存器。
绝对跳转和函数调用可以指向符号,比如foo(SB)
,但是不能带有偏移,比如foo+4(SB)
。
函数与数据定义
函数
与其他风格的汇编类似,Go汇编也需要为函数或数据指明它们所在的节,如TEXT节或DATA节。但是Go汇编在定义每个函数或变量时都要明确指定:
1 | TEXT runtime.profileloop(SB),NOSPLIT,$8 |
TEXT指令后面分别是函数名、函数标记(flags)和帧大小(字节)。通常情况下,帧大小后面跟着一个参数大小,并用负号分隔,如$24-8
,其中帧大小为24字节,单个参数大小为8字节。由于这里使用了NOSPLIT
函数标示,可以不提供参数大小。有时候函数没有栈帧,可以将帧大小设置为0。同样的,有些函数也没有参数和返回值,可以将参数大小设置为0。在函数的最后,必须是短跳转指令或RET指令,如果不是,Go链接器会在函数后面添加一个跳转到该函数自身的指令。
全局变量
全局数据的定义可以使用DATA
指令配合GLOBL
指令:
1 | DATA symbol+offset(SB)/width, value |
其中offset
为相对于symbol
的偏移,width
为数据宽度,value
为数据的值。
这样就会分配相应要求的内存空间并存储数据。
然后再使用GLOBL
指令来声明此变量为全局的。
1 | GLOBL 名称(SB), 标识, 大小(字节) |
举例:
1 | DATA divtab<>+0x00(SB)/4, $0xf4f8fcff |
就是从divtab
为起点开始存储4字节4字节的具体数据,一共64字节,然后声明全局变量divtab
,且其属性是RODATA
,大小是64字节。
同时还声明了另一个全局变量,4字节大小,没有指针。未用DATA
初始化,所以默认为0。
标识列表(未翻译)
NOPROF
= 1
(ForTEXT
items.) Don’t profile the marked function. This flag is deprecated.DUPOK
= 2
It is legal to have multiple instances of this symbol in a single binary. The linker will choose one of the duplicates to use.NOSPLIT
= 4
(ForTEXT
items.) Don’t insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space remaining in the current stack segment. Used to protect routines such as the stack splitting code itself.RODATA
= 8
(ForDATA
andGLOBL
items.) Put this data in a read-only section.NOPTR
= 16
(ForDATA
andGLOBL
items.) This data contains no pointers and therefore does not need to be scanned by the garbage collector.WRAPPER
= 32
(ForTEXT
items.) This is a wrapper function and should not count as disablingrecover
.NEEDCTXT
= 64
(ForTEXT
items.) This function is a closure so it uses its incoming context register.LOCAL
= 128
This symbol is local to the dynamic shared object.TLSBSS
= 256
(ForDATA
andGLOBL
items.) Put this data in thread local storage.NOFRAME
= 512
(ForTEXT
items.) Do not insert instructions to allocate a stack frame and save/restore the return address, even if this is not a leaf function. Only valid on functions that declare a frame size of 0.TOPFRAME
= 2048
(ForTEXT
items.) Function is the outermost frame of the call stack. Traceback should stop at this function.
使用Go类型/常量
If a package has any .s files, then
go build
will direct the compiler to emit a special header calledgo_asm.h
, which the .s files can then#include
. The file contains symbolic#define
constants for the offsets of Go struct fields, the sizes of Go struct types, and most Goconst
declarations defined in the current package. Go assembly should avoid making assumptions about the layout of Go types and instead use these constants. This improves the readability of assembly code, and keeps it robust to changes in data layout either in the Go type definitions or in the layout rules used by the Go compiler.
在Go汇编中,常量是const_name
的形式。比如在一般Go语言中声明const bufSize = 1024
,那么汇编码中可以通过const_bufSize
来获取。
结构体的成员偏移则是type_field
。结构体的大小是type_size
。比如考虑一下结构体:
1 | type reader struct { |
通过reader_size
获得大小,reader_buf
和reader_r
获得结构体成员偏移。
所以如果R1
寄存器指向了reader
结构体头,则reader_buf(R1)
即可指向成员buf
。
Runtime Coordination
程序运行时的垃圾回收问题。略。
具体架构细节
完全列出某个架构所有指令是不现实的。所以为了研究什么指令是用于什么架构的,比如说ARM
架构,看源码src/cmd/internal/obj/arm
库文件即可。
1 | const ( |
其他架构同理。
值得注意的是,数据流是从左往右的,即MOVQ $0, CX
将清空CX
。
32-bit Intel 386
指向结构体g
(某任意结构体)的运行时指针会被一个没被用上的寄存器维护。在运行库中,汇编码可以包含go_tls.h
,这个库定义了基于OS和架构的宏get_tls
,用于获取这个寄存器。get_tls
有一个形参,此参便是将要获得g
指针的寄存器。
比如,通过CX
,BX
寄存器获取g
和其子成员m
的指针
1 | #include "go_tls.h" |
get_tls
宏在AMD64
架构上也被定义了。
取地址偏移:
(DI)(BX*2)
是DI
+BX*2
的地址处64(DI)(BX*2)
是DI
+BX*2
+64
。只能接受1,2,4,8的乘法。
When using the compiler and assembler’s -dynlink
or -shared
modes, any load or store of a fixed memory location such as a global variable must be assumed to overwrite CX
. Therefore, to be safe for use with these modes, assembly sources should typically avoid CX except between memory references.
64bit Intel 386 (AMD64)
除了使用MOVQ
而非MOVL
以外基本没变。
BP
寄存器是被调用函数恢复的。汇编器会自动添加BP
平衡指令,如果当前栈帧大于等于0。使用BP
寄存器作为一般用途寄存器是被允许的,但不建议。
其他RISC架构
略,详见官方博客。
Unsupported opcodes
稍微讲了下开发者面对Go汇编器不认识的机器平台指令时的处理方式。
The assemblers are designed to support the compiler so not all hardware instructions are defined for all architectures: if the compiler doesn’t generate it, it might not be there. If you need to use a missing instruction, there are two ways to proceed. One is to update the assembler to support that instruction, which is straightforward but only worthwhile if it’s likely the instruction will be used again. Instead, for simple one-off cases, it’s possible to use the BYTE
and WORD
directives to lay down explicit data into the instruction stream within a TEXT
. Here’s how the 386 runtime defines the 64-bit atomic load function.
1 | // uint64 atomicload64(uint64 volatile* addr); |
AMD64下Go函数调用底层分析
基于 The Go low-level calling convention on x86-64 · dr knz @ work (dr-knz.net) 配合高版本分析
参数/返回值/序列调用
参数和返回值
1 | func EmptyFunc() {} // 无参无返回 |
EmptyFunc
1 | c3 RET |
直接返回
FuncConst
1 | 48c74424087b000000 MOVQ $0x7b, 0x8(SP) |
小于SP
地址的是局部栈帧(见上文),0x8(SP)
则是栈帧外边的第2个QWORD,将会作为此函数的返回值。(第一个QWORD0(SP)
是CALL
指令压进栈的返回地址)
FuncAdd
1 | 488b442408 MOVQ 0x8(SP), AX |
0x8(SP)
等2个参数皆是局部栈帧外部,高地址位传来的的参数。操作在AX
寄存器上直接完成,没有利用任何局部变量。
可见传参与返回全是基于栈的。
函数调用
1 | func DoCallAdd() int { return FuncAdd(1, 2, 3) } |
1 | 65488b0c2528000000 MOVQ GS:0x28, CX |
在函数前的检查结束后,代码中心便是具体调用。
1 | 48c7042401000000 MOVQ $0x1, 0(SP) ; 第一个参数 |
当然,为了给传参预留空间,调用者函数需要分配与回收一段栈空间。
1 | 4883ec30 SUBQ $0x30, SP |
同时,因为Go有Panic机制,出错时要有栈回溯的能力,所以必须存储入口时栈指针和调用者栈指针的差异。
1 | ; Store the frame pointer of the caller into a known location in |
大概栈中的样子:
寄存器地址指向 | 内存内容 | 描述 |
---|---|---|
main.main BP | main函数维护的BP位置 | |
…. | 预留的栈空间 | |
main.main SP(before call) | 调用前一刻SP所处位置 | |
SP*(called) | addr | 调用后,压栈了下一条指令的地址;SP* = SP - 8 |
BP* | BP | 位于SP**+0x28 处,恰好在返回地址下方;新旧BP构成了链表 |
SP** | SP** = SP* - 0x30 ,DoCallAdd() 函数内预留栈空间 |
这个就是用于异常处理的,跟SEH原理很类似。通过链表进行回溯。一旦出错,新BP
立刻取内存内的值,获得旧BP
的值,从而相对上将现在错误函数的栈帧全部弹掉。
Go还有“小代码”优化。即程序一开始只会被分配很小的一块内存区域。所以只要是需要一定栈空间的函数,都会首先检查现在Go routine分配的够不够。通过将现在的SP
指针和Go routine最低水位线对比来实现。
1 | 65488b0c2528000000 MOVQ GS:0x28, CX |
若不够,则会CALL runtime.morestack_noctxt(SB)
来分配部分空间,然后再次跳回函数顶部,如此循环直到内存空间充足。
(补充)C/C++中的异常处理
JL Schilling - Optimizing away C++ exception handling - ACM SIGPLAN Notices, 1998
被调用函数是否拥有保留寄存器
其它语言中,可能会处于优化的目的将一些经常用到的值直接保留在寄存器上,而不会再压入栈中。Go语言中,却总是会将变量入栈。
1 | func Intermediate() int { |
1 | Intermediate: |
指针花销和接口
Go同时实现了指针类型,以及拥有vtable
的接口类型。
只用一个QWORD的指针
1 | func UsePtr(x *int) int { return *x } // 解引用 |
1 | UsePtr: |
所以指针大小跟一个QWORD一样。
1 | var x int |
1 | RetPtr: |
使用2个QWORD的接口(非空接口)
1 | type Foo interface{ foo() } |
1 | InterfaceNil: |
使用的是一个xmm
寄存器,128位,2个QWORD
1 | func InterfacePass(x Foo) Foo { return x } |
1 | 4.go:11 0x491fa0 0f57c0 XORPS X0, X0 |
虽然只有1个参数,但是复制了2个QWORD。
使用2或3个QWORD的字符串或切片
字符串
string
由2个QWORD组成
1 | type StringHeader struct { |
切片
slice
由3个QWORD组成
1 | type SliceHeader struct { |
与切片的结构体相比,字符串少了一个表示容量的 Cap
字段,因为字符串作为只读的类型,我们并不会直接向字符串直接追加元素改变其本身的内存空间,所有在字符串上执行的写入操作实际都是通过拷贝实现的。
构造接口值
- 刚刚入门Go语言,接口的意义和实现花了我挺久时间研究。
- 基于版本1.16.6。据说与老版本go1.10稍有差异
通过结构指针构造
1 | package main |
1 | TEXT main.MakeInterface1(SB) C:/Users/任昊/Desktop/SSTudy/Go/Blog2/5.go |
通过
1 | 0x4a7b48 48894c2418 MOVQ CX, 0x18(SP) ; +0x18偏移,vtable |
可以得知创建的接口中,第一个QWORD指向了vtable
,第二个则指向具体结构体。
通过结构构造
1 | package main |
1 | 6.go:27 0x4a7b20 65488b0c2528000000 MOVQ GS:0x28, CX |
通过直接传结构体构造接口,代码量会大不少。因为它会再调用runtime.convT64(SB)
函数。
去掉前言和结尾,得到核心块
1 | 6.go:27 0x4a7b4c 488b0515411000 MOVQ main.janny(SB), AX |
即会调用runtime.convT64(janny)
来获得一个裸指针,然后调用者函数再将Sayer vtable
和它接在一起。
除了runtime.convT64
以外还有好几个相似函数,用于不同大小的数据和类型。
convT2I16
,convT2I32
,convT2I64
for “small” types;convT2Istring
,convT2Islice
forstring
and slice types;convT2Inoptr
for structs that do not contain pointers;convT2I
for the general case.
其中此示例代码如果是老版本编译,会遇到convT2I64
函数,现在处于优化基本都用convT64I
这个泛型函数了。
convT64
的用途就是调用堆分配器,分配一块空间来获取对象的一个副本,返回一个指针。
空结构体的接口
根据实践发现,空结构体的接口其实就是空接口。
1 | package main |
其中的empty
结构体,没有定义成员,其方法也是没有任何操作。
1 | 7.go:13 0x491fc0 0f57c0 XORPS X0, X0 |
而用interface{}
明确声明的空接口,编译后
1 | package main |
1 | ; MakeInterface3 |
两者汇编基本一致。需要知道的就是其第二个QWORD都是runtime.zerobase
函数地址,且由于无需任何函数调用,所以没有用于检查栈帧的函数前言和尾部。
error
是一种接口类型
1 | package main |
Simple1()
1 | 8.go:3 0x462720 48c744240800000000 MOVQ $0x0, 0x8(SP) |
Simple2()
1 | 8.go:4 0x462740 48c744240800000000 MOVQ $0x0, 0x8(SP) |
可以发现它第二个参数使用了xmm
寄存器,是接口形式。
计算错误
便于观察,故错误抛出是被定义为全局变量了。
1 | package main |
1 | ; x : 0x8(SP) |
1 | 9.go:12 0x4a7b48 488b0501030c00 MOVQ main.errDivByZero(SB), AX |
一样。
以下是更一般的情况。即错误抛出通过函数实时调用,所以会产生复杂的函数前言和尾声。
1 | func Compute(x, y float64) (float64, error) { |
1 | ; x = 0x90(SP) |
扔到IDA反编译后
1 | __int64 *__usercall main_Compute@<rax>(__int64 a1, double a2) |
可以发现IDA居然把核心代码块给忽略掉了!所以说Go程序的伪代码不能完全相信。
测试错误
1 | package main |
1 | 11.go:13 0x4a6ea0 65488b0c2528000000 MOVQ GS:0x28, CX |
补充一下fmt.Errorf
的源码实现
1 | // src/fmt/errors.go |
其中第二个参数是个可变参数+空接口。后面再去分析这两个玩意。
defer
的实现
defer
简介:defer
语句用于延迟函数的调用,每次 defer
都会把一个函数压入栈中,函数返回前再把延迟的函数取出并执行。Golang 中的 defer
可以帮助我们处理容易忽略的问题,如资源释放、连接关闭等。
1 | package main |
运行结果
1 | 114 |
可以发现defer
确实将f()
函数给推迟了。使得它在函数返回的时候才被调用,所以514
是在1919810
之后打印的。
汇编:
1 | TEXT main.Defer1(SB) C:/Users/任昊/Desktop/SSTudy/Go/Blog2/12.go |
首先我们看到对runtime.deferprocStack()
的调用。在go/src/runtime/panic.go
中看源码:
1 | // deferprocStack queues a new deferred function with a defer record on the stack. |
anyway,大概的意思就是在栈上创建了一个defer record
。然后根据函数原型,我们再查查结构体_defer
是个啥。
在runtime/runtime2.go
中
1 | type _defer struct { |
不过由于传入的是个指针,所以只需要一个QWORD传入函数里。但是由于_defer
结构体中的第一个成员siz
和偏移0x18成员fn
都需要初始化,所以可以看到:
1 | ; runtime.deferprocStack(*_defer), _defer.siz = 0; _defer.fn = go.func.*+178(f函数) |
从0x30到0x48正好偏移0x18,可以推测前面这几行就是初始化结构体,且结构体在栈上。(对应了函数的介绍,defer record on the stack
)
然后立刻进行TEST
,看有没有产生panic
。
最后在RET
前调用deferreturn()
触发预置函数。
defer
闭包
一般情况下我们都会用闭包函数触发defer
。
1 | func Defer2() (res int) { // 返回值res |
这个程序最终将返回123而非-1。
1 | TEXT main.Defer2(SB) |
总结一下:编译后闭包函数会变成一个匿名函数,此匿名函数有一个默认传入参数,即上级函数的栈帧指针。通过取得它便可修改上级函数里面的变量值,实现对返回值的修改。
panic
的实现
在函数中使用panic()
panic
就是错误抛出
1 | package main |
得到
1 | TEXT main.Panic1(SB) |
日常源码:
1 | // runtime/panic.go/gopanic |
传入任意一种接口,所以在传入的参数会在编译时转化成接口类型后再调用。
Panic1()
interface{nil}
会被优化成传入1个XMMWORD的0
Panic2()
interface{x}
中的第一个QWORD传入的是一个默认类型作为vtable
,第二个就是x
变量的指针指向其value
。
捕捉异常:defer
+recover
Go中没有try-catch
异常捕获机制,但是提供了recover()
伪函数来实现这一过程。
当程序员想要捕获foo()
或其调用的函数中的异常时,需要
- 一个单独的函数或者闭包函数(反正不能是
foo
自己),且包含一个recover()
调用 - 必须从
foo()
函数用defer
来调用这个独立的函数。
1 | func Recovering(r *int) { |
1 | TEXT main.Recovering(SB) |
1 | func gorecover(argp uintptr) interface{} { |
返回了个g._panic.arg
且转换成了interface{}
类型,所以有2QWORD。这个函数大概就是获取goroutine
结构体g
,然后查看有没有发现正在进行的异常,然后相应的返回特定值。没有异常就返回nil
。
gccgo
全新的编译器实现,又是一个很大的类。不过我不打算仔细研究。稍微了解即可。
即使用gcc
编译器对Go语言进行编译,其支持的优化相比传统默认Go编译器有着比较大的改进。
https://go.dev/doc/install/gccgo
里面有安装编译教程。
我直接在Ubuntu上面
1 | sudo apt install gccgo |
即可获取最新版本gccgo
。
1 | package main |
和一般的gcc
没啥区别。
1 | gccgo 1.go -o 1_gcc |
1 | ./1_gcc |
看一下IDA。
1 | void __cdecl main_main() |
Go的分析也很依赖汇编,所以看一下汇编码
1 | .text:00000000000033B0 ; int __cdecl main(int argc, const char **argv, const char **envp) |
值得注意的是
1 | .text:0000000000003795 mov edx, 3 |
原来Go语言中利用栈的临时变量传递方法会被优化成寄存器传递。
再开个O3试试。
1 | gccgo -O3 1.go -o 1_o3_gcc |
1 | .text:00000000000035D0 ; void __cdecl main_main() |
压缩更厉害了。
1 | void __cdecl main_main() |
反编译结果是这样的。大抵能知道调用了什么函数,但是具体传了什么参数还是得看汇编。
看第一个fmt.Println("AAA")
1 | .text:00000000000035DF push rbx |
反编译出来_fmt_Println(&v2, 1, 1)
,第一个参数是一个string
类结构体,上面能看到有个lea rax, go__C6
1 | .data.rel.ro:00000000000078F0 go__C6 dq offset aAaa ; DATA XREF: main_main+17↑o |
这就是string
类结构体。且字符串已经变成了C风格字符串,以\x00
结尾的那种。
剩下的2个传入1的参数暂时不管,似乎是空接口的其他参数。
补充
关于Go接口
GO根据接口类型是否含有方法集
将接口分为了两类:
iface
非空接口,含有一组方法集eface
空接口,不含方法集
iface
1 | type iface struct{ |
itab
指针指向一个itab
结构体,该itab
结构体记录了该接口值的一系列信息,包括接口静态类型信息,持有数据的动态类型信息,方法集等,用以进行 接口的类型转换,编译器类型检查,辅助反射等等;即:非空接口的itab
既包含接口类型相关的信息,又包括所持数据的类型相关的信息data
是一个指向实际数据的指针
具体来看一下itab
,这个结构体还是很重要的,是GO接口实现的基础。
1 | type itab struct { // 32 bytes |
从iface
或itab
都可以看出,接口interface包含有两种类型:
- 一种是接口自身的类型,称为接口的静态类型,比如
io.Reader
等,用于确定接口类型,直接存储在itab
结构体中 - 一种是接口所持有数据的类型,称为接口的动态类型,用于在反射或者接口转换时确认所持有数据的实际类型,存储在运行时
runtime/_type
结构体中。
hash
是一个uint32
类型的值,实际上可以看作是类型的校验码,当将接口转换成具体的类型的时候,会通过比较二者的hash
值确定是否相等,只有hash
相等 才能进行转换。
fun
最终指向的是接口的方法集,即存储了接口所有方法的函数的指针。通过比较接口的方法集和类型的方法集,可以用来判断该类型是否实现了该接口。 把fun
指向的方法集看作是一个虚函数表,也是很贴切的。
最后再简单看一下运行时runtime/_type
结构体。该结构体包含了GO类型的所有类型信息,如类型大小/类别/哈希等等。 只需要有这个概念就好。
1 | type _type struct { |
eface
空接口类型也是接口类型的一种,只不过它没有方法集,同时空接口也是GO实现多态的基础,因此将空接口进行单独定义,一来简化底层数据结构,二来更好的支持GO的运行时多态。
1 | type eface struct{ // 两个指针,16byte |
第一个参数直接就是_type
结构体指针了,而不是拥有更多具体描述的itab
。
接口的nil
和non-nil
从上面接口的底层数据结构可以看出,接口总是存在两个指针类型的字段。因此,当且仅当该接口值的两个字段都是nil
的时候,该接口==nil
才成立。
更不能想当然的认为空接口==nil,因为判断接口是否等于nil只有一种条件,即其两个字段都是nil。
1 | func main() { |
简单点的意思就是类型和值都得是nil
才行。
参考
The Go low-level calling convention on x86-64 · dr knz @ work (dr-knz.net)
18年很详细的举例说明文章。
1.4 Plan 9 汇编语言 | Go 语言原本 (golang.design)
https://quasilyte.dev/blog/post/go-asm-complementary-reference/#register-names
- 本文作者: Taardis
- 本文链接: https://taardisaa.github.io/2022/02/24/Go Reverse 2/
- 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!